刚进进入公司差不多一个月左右就被老大安排跟着老大做简拼中一个相册的模块:云相册。到目前为止历时近 5 个月时间模块的开发经过相关的测试也上线。
- 云相册来源
公司原来把用户相册上传到自己的服务器上面。但是经过后台和我们老大民哥商量打算把相册保存在阿里云上。目前相册的基本功能都已经实现也经过相关的测试,但考虑到用户体验和节省流量情况下打算对上传照片进行缓存处理。
为什么采用缓存?
下面小编就把与相册图片展示的下载过程列出来:
上图我们看到图片在下载过程的流程图
简拼的用户有近千万级,每天访问量都比较大。而且在下载过程中服务器和数据库服务器所做的计算也越来越多。但是目前公司都是托管在阿里云服务器,而且公司要节省费用带宽有一定的限制。所以就引入缓存,打破标准流程,每个环节中请求可以从缓存中直接获取目标数据并返回,从而减少计算量,有效提升响应速度,让有限的资源服务更多的用户。
缓存方式
查找下资料目前我们实现缓存的方式有:内存缓存,磁盘缓存和数据库。
内存缓存:
将缓存储存在内存中可以很快的读取,而且在读取的过程中不需要额外的
I/0
开销。但是内存缓存哟一个致命的缺陷:在程序关闭后就会完全清除,数据很难或者根本不会恢复。
磁盘缓存:
一般情况下很多的缓存框架都是内存缓存和磁盘缓存结合使用。在内存缓存空间充满或者是异常关闭的情况下,可以将内存中的数据储存到数据持久化的硬盘中。
这样就可以达到释放空间或者是数据备份的目的。
数据库:
增加缓存的原因之一:减少数据库的
I/0
的压力。使用数据库做缓存介质是不是又回到了这个问题上了?其实,数据库也有很多种类型,像那些不支持SQL
,只是简单的key-value
存储结构的特殊数据库(如BerkeleyDB
和Redis
),响应速度和吞吐量都远远高于我们常用的关系型数据库等。
缓存特征
缓存是一种数据模型对象,其中也有一些特征。例如:命中率、最大元素空间和清空策略。
命中率
命中率 = 返回正确结果数/请求缓存次数。
命中率问题是缓存中的一个非常重要的问题,它是衡量缓存有效性的重要指标。命中率越高,表明缓存的使用率越高。
最大元素
缓存中可以存放的最大元素的数量,一旦缓存中元素数量超过这个值(或者缓存数据所占空间超过其最大支持空间),那么将会触发缓存启动清空策略根据。
(1)不同的场景合理的设置最大元素值往往可以一定程度上提高缓存的命中率,从而更有效的时候缓存。
(2)NSCache
在内存发出警告时会启动清楚,其限制大小<
为500M
。
清空策略
上面👆小编提到内存缓存有一定的空间限制。当缓存空间被用满时,系统怎么样保证在稳定服务的同时有效提升命中率?
这就由缓存清空策略来处理,设计适合自身数据特征的清空策略能有效提升命中率。
常见的一般策略有:
FIFO(first in first out)
先进先出策略,最先进入缓存的数据在缓存空间不够的情况下(超出最大元素限制)会被优先被清除掉,以腾出新的空间接受新的数据。
策略算法主要比较缓存元素的创建时间。
在数据实效性要求场景下可选择该类策略,优先保障最新数据可用。
LFU(less frequently used)
最少使用策略,无论是否过期,根据元素的被使用次数判断,清除使用次数较少的元素释放空间。
策略算法主要比较元素的hitCount
(命中次数)。
在保证高频数据有效性场景下,可选择这类策略。
LRU(least recently used)
最近最少使用策略,无论是否过期,根据元素最后一次被使用的时间戳,清除最远使用时间戳的元素释放空间。
策略算法主要比较元素最近一次被get
使用时间。
在热点数据场景下较适用,优先保证热点数据的有效性。
还有一些简单策略比如:
(1)根据过期时间判断,清理过期时间最长的元素;
(2)根据过期时间判断,清理最近要过期的元素;
(3)随机清理;
(4)根据关键字(或元素内容)长短清理等。
缓存和 Sqlite
架构探索
小编在上面👆讲述缓存方式和缓存特征中讲到目前很多多数缓存框架是基于:缓存和 Sqlite
结合。
使用缓存多数是可以使用与一种情况:多频率的读取,少数写入。
实例
用户的余额信息表
account(uid, money)
,业务上的需求是:(1)查询用户的余额,
SELECT money FROM account WHERE uid=XXX
,占99%
的请求;
(2)更改用户余额,UPDATE account SET money=XXX WHERE uid=XXX
,占1%
的请求。
上面的请求数据的方式中:读取数据频率远大于修改数据的频率,所以可以采用缓存方式来减轻数据库的压力。
上面👆我们把数据储存在缓存和数据库中,在每次单独去数据操作流程如下:
(1)读取缓存中是否有相关数据,
uid->money
;
(2)如果缓存中有相关数据money
,则返回 [这就是所谓的数据命中“hit”
];
(3)如果缓存中没有相关数据money
,则从数据库读取相关数据money
【这就是所谓的数据未命中“miss”
】,放入缓存中uid->money
,再返回。
缓存的命中率 = 命中缓存请求个数/总缓存访问请求个数 = hit/(hit+miss)
上面举例的余额场景,99%
的读,1%
的写,这个缓存的命中率是非常高的,会在 95%
以上。
数据修改探索
在数据 money
发生变化的时候:
(1)是更新缓存中的数据,还是淘汰缓存中的数据呢?
(2)是先操纵数据库中的数据再操纵缓存中的数据,还是先操纵缓存中的数据再操纵数据库中的数据呢?
- (3)缓存与数据库的操作,在架构上是否有优化的空间呢?
缓存更新 VS 淘汰
更新:
数据既要写入数据库也会写入缓存之中,这样缓存不会增加一次miss,命中率高。
淘汰:
数据只会写入数据库不会写入缓存,数据只会淘汰掉数据。
(1)情景A(更新缓存的复杂化)
如果仅仅只是更改当前一个值的话小编认为可以采用更新缓存的方式。
(2)情景B(更新缓存需要进行计算)
实例
例如业务上除了账户表
account
,还有商品表product
,折扣表discount
account(uid, money)
product(pid, type, price, pinfo)
discount(type, zhekou)
业务场景是用户买了一个商品 product
,这个商品的价格是price
,这个商品从属于type
类商品,type
类商品在做促销活动要打折扣zhekou
,购买了商品过后,这个余额的计算就复杂了,需要:
(1)先把商品的品类,价格取出来:
SELECT type, price FROM product WHERE pid=XXX
;
(2)再把这个品类的折扣取出来:SELECT zhekou FROM discount WHERE type=XXX
;
(3)再把原有余额从缓存中查询出来money = getCache(uid)
;
(4)再把新的余额写入到缓存中去setCache(uid, money-price*zhekou)
更新缓存的代价很大,此时小编认为应该更倾向于淘汰缓存。
- 淘汰缓存的实用率比较高,其副作用只是在过程中会产生数据的一次
miss
。在开发过程中可以普遍采用淘汰缓存的策略。
先操作缓存数据 VS 先操作数据库数据
在数据修改过程中小编认为会面临一个问题:缓存的操作和数据库的操作先后顺序。
- (1)先写数据库,再淘汰缓存;
- (2)先淘汰缓存在写数据库。
(1)先写数据库,再淘汰缓存
采用此种方式如果在写数据库成功后,对缓存内容进行修改失败。这样我们获取的数据就是 旧数据,最后结果是获取的数据不一致。
(2)先淘汰缓存在写数据库
采用这个方法,如果淘汰缓存成功后再去操作数据库失败的情况下,我们获取数据的结果就是 miss
。
这样只是引发我们在获取数据失败的情况,相比于(1)获取的数据不一致的错误而言。方法(2)更好。
注:在数据操作的过程中我们采用,先淘汰缓存在写数据库的做法。
数据不一致
数据不一致的产生
上面👆我们总结数据修改方案和数据操作在缓存和数据库之间的操作顺序,下面罗列出数据操作写和读取的大致流程:
写数据
(1)先淘汰缓存;
(2)在更新数据库。读取数据
(1)先读取缓存中的数据,如果数据读取
hit
返回;
(2)如果数据没有获取miss
就去读取 数据库的数据;
(3)将数据库中的数据读取放入缓存。
但是上面操作在分布式开发过程中会出现数据不一致的情况:
有多个应用,通过一个服务的多个部署(为了保证可用性,一定是部署多份的),对同一个数据进行读写,在数据库层面并发的读写并不能保证完成顺序,也就是说后发出的读请求很可能先完成(读出脏数据):
(1)发生了写请求
A
,A
的第一步淘汰了cache
;
(2)A
的第二步写数据库,发出修改请求;
(3)发生了读请求B
,B
的第一步读取cache
,发现cache
中是空的;
(4)B
的第二步读取数据库,发出读取请求,此时A
的第二步写数据还没完成,读出了一个脏数据放入cache
;
即在数据库层面,后发出的请求4
比先发出的请求2
先完成了,读出了脏数据,脏数据又入了缓存,缓存与数据库中的数据不一致出现了。
数据不一致的优化
能否做到先发出的请求一定先执行完成呢?常见的思路是“串行化”,今天小编将和大家一起探讨“串行化”这个点。
service
服务实现实现串行访问
先一起细看一下,在一个服务中,并发的多个读写SQL一般是怎么执行的?
如右图:
有上图可以看出 service
服务的上下游及服务内部详细展开,细节如下:
(1)
service
的上游是多个业务应用,上游发起请求对同一个数据并发的进行读写操作,上例中并发进行了一个uid=1
的余额修改(写)操作与uid=1
的余额查询(读)操作;
(2)service
的下游是数据库DB
,假设只读写一个DB
;
(3)中间是服务层service
,它又分为了这么几个部分;
(3.1)最上层是任务队列;
(3.2)中间是工作线程,每个工作线程完成实际的工作任务,典型的工作任务是通过数据库连接池读写数据库;
(3.3)最下层是数据库连接池,所有的SQL语句都是通过数据库连接池发往数据库去执行的。
工作线程的实现方式:
1 | void work_thread_routine(){ |
- 客户端(
client
)实现串行访问
下图是应用层上下游及内部结构:
业务应用的上下游及服务内部详细展开,细节如下:
(1)业务应用的上游不确定,可能是直接是
http
请求,可能也是一个服务的上游调用;
(2)业务应用的下游是多个服务service
;
(3)中间是业务应用,它分为了几个部分;
(3.1)最上层是任务队列,或许web-server
例如:tomcat
帮我们干了这个事情了;
(3.2)中间是工作线程,或许web-server
的工作线程或者cgi
工作线程帮你干了线程分派这个事情。每个工作线程完成实际的业务任务,典型的工作任务是通过服务连接池进行RPC
调用;
(3.3)最下层是服务连接池,所有的RPC
调用都是通过服务连接池往下游服务去发包执行的。
工作线程的实现方式:
1 | voidwork_thread_routine(){ |
小编zai 上面会给出
service
和client
两个情况访问数据实现串行化解决方式来解决在访问或者修改数据多应用的情况下实现缓存和数据的一致性。
(1)service
在读取数据时采用:同一个数据的访问能串行化;
(2)client
在读写数据his采用:同一个数据的读写都落在同一个后端服务上。